Skip to content

feat(response-viewer): add a Copy Markdown button - #810

Merged
Ark0N merged 1 commit into
Ark0N:masterfrom
aakhter:pr/response-copy-markdown
Nov 12, 2026
Merged

Ark0N merged 1 commit into
Ark0N:masterfrom
aakhter:pr/response-copy-markdown

Conversation

@aakhter

@aakhter aakhter commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

Problem

The response viewer renders an agent's answer, so selecting it loses the source: headings, list markers and code fences do not survive a paste. The only source copy today is the per-code-block button.

Change

Adds a Copy Markdown button to the viewer's action row. It copies the raw source of the newest answer:

  • every row of that answer's turn, joined by a blank line, in both the brief view and the full (More) view
  • the last response block when the reader emits no numeric turns (Codex, the pane parser)
  • never a tool or status block (they share the assistant role) and never a prompt

The copied text is recorded where the views render, so a reopened viewer never copies an earlier answer. An empty viewer warns and copies nothing. A refused clipboard goes through the existing _copyText fallback and reports failure only if that fails too.

_setCopyControlState replaces the inline feedback logic in the code-block button rather than duplicating it. Feedback colors use the same --green/--red tokens as that button. On phones the header title now truncates so the longer action row stays on screen.

No server change, no new route, setting, flag or SSE event.

For review

  • The button is always present in the viewer, with no setting. The viewer itself is already opt-in (showResponseViewer). Tell me if you would rather gate it.
  • In the full view the button copies the last answered turn, not the whole conversation.
  • If the full view holds no answer of its own, the button keeps the brief view's answer.

Verification

  1. Surface gate: does not apply. No /api/v1 route, CLI verb, flag, skill description or default changes.
  2. Side-by-side sweep vs master: does not apply. Nothing parses terminal output; the existing render paths are unchanged, and the only source of the copied text is the route's existing text/messages.
  3. Input modes and contexts: mouse click on desktop and tap on phone emulation (320 and 390 wide). Wheel, pinch and keyboard do not reach this control beyond a normal button. Multi-user, tunnel, Docker and remote were not run: the button only reads what the viewer already fetched through the same route, with no new request.
  4. Bounds: no regex and no IO added; one linear pass over messages already fetched.
  5. Real scale: not run at 10x. The cost is one linear filter over the list the viewer already rendered.
  6. Live: isolated instance on 127.0.0.1 with a real Claude Code 2.1.316 pane, tmux next-3.7, Chromium via Playwright 1.60, real clipboard read back.
    • Brief view: clipboard equals the real turn's two rows joined, Checking now.\n\n### Result\n\n**Done**, while the viewer shows them rendered
    • More view (Conversation (5 messages, 2 turns)): same text, from the last turn only
    • Code-block button still copies through the shared helper: const a = 1;\n
    • Feedback class clears after 1.5 s; own toast does not cover the button; no page errors
    • Clipboard permission really denied through CDP: writeText rejects NotAllowedError, the real execCommand fallback succeeds, so the button reports success, which is accurate
    • Denied with execCommand stubbed to false: rv-copy-failed and Failed to copy response as Markdown
    • Empty viewer: warning toast, failed state, clipboard sentinel untouched
    • Tool and status blocks excluded, using a route stub with the real envelope. Codex and pane-parser shapes were checked through stubs and unit tests only, not a live pane
    • 320 wide with a long title: title truncates, close button at x=308, no document overflow
    • zh-CN: button text, title, aria-label and toast all translated
  7. Tests bite: 15 new tests in test/response-viewer-copy-markdown.test.ts, all failing before the change except the code-block one, which guards the refactor and is killed by its own mutation. 22 mutations, 22 killed. Two survived the first pass: one had hit a different rule (retargeted, killed), and one showed a generic Failed to ... i18n rule hid a missing entry, so the test now asserts exact strings (killed).
  8. Reuse: _copyText, showToast, .response-viewer-more, the existing color tokens.
  9. Docs: docs/wiki/Watching-Agents-Work.md, docs/wiki/Mobile-Guide.md, zh-CN strings, changeset. API reference, Settings-Reference, READMEs, skills/codeman and AGENTS.md have nothing that describes the viewer's actions.
  10. Error and exit codes: does not apply. No status or code returned.
  11. Fixtures: synthetic text only.
  12. Scope: this button only.

Gates on this head (rebased onto 8bedbbe, 1.50.1): typecheck, lint, format:check, check:frontend-syntax, check:public-assets and check:browser-excludes pass. The new test file plus nine response-viewer, i18n, header-policy and skin suites: 128 of 128 pass. A build of this head loaded in Chromium on an isolated 127.0.0.1 instance renders the button inside the viewer with no page errors.

npm run test:ci (run before the rebase, on f1f7eec): branch 6 failed, 13513 passed; plain master 7 failed, 13497 passed. The same 6 fail on both, all in test/tmux-prefix-table.test.ts on this machine's tmux next-3.7; the 7th on master was test/frontend-public-tooling.test.ts, from a baseline checkout with no spreadsheet assets prepared. No failure was new on the branch. The live checks in item 6 were also run on that base.

The viewer renders the agent's answer, so selecting it loses the source:
headings, list markers and code fences do not survive. The only source copy
was the per-code-block button.

Copy Markdown copies the raw source of the newest answer the viewer holds:
every row of its turn, or the last response block when the reader emits no
turns. Tool and status blocks share the assistant role and are left out. The
copied text is recorded where the views render, so a reopened viewer never
copies an earlier answer, and an empty viewer warns instead of copying.

The feedback helper replaces the inline copy of the same logic in the
code-block button. On phones the header title now truncates so the longer
action row stays on screen.
@Ark0N

Ark0N commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @aakhter for this. It adds a Copy Markdown button to the response viewer that copies the raw source of the newest answer, so headings, lists and code fences survive a paste. The selection logic matches what each view renders, the _setCopyControlState extraction leaves the code-block button's behaviour unchanged, and the zh-CN strings and phone layout check out (I measured 320 and 390 wide against the real stylesheets).

Two small things:

  1. No test reaches src/web/public/app.js:3364. The fallback || this._responseViewerMarkdown keeps the brief answer when the full view has none of its own. The test at test/response-viewer-copy-markdown.test.ts:173 serves messages: [], which returns at the messages.length === 0 branch before that line runs, so you can remove the fallback and all 15 tests stay green. A full view that holds only a prompt reaches the line:

    const app = makeApp(
      { text: 'the brief answer' },
      { messages: [{ kind: 'prompt', role: 'user', text: 'a question', turn: 1 }] }
    );
    await app.toggleResponseViewer();
    await app.loadFullContext();
    await app.copyResponseViewerMarkdown();
    expect(app._copyText).toHaveBeenCalledWith('the brief answer');

    This passes on your head and fails once the fallback is gone.

  2. The accessible name at src/web/public/index.html:736. The button reads "Copy Markdown", but aria-label="Copy response as Markdown" replaces that as its accessible name, and the name doesn't contain the words on the button. WCAG 2.5.3 (Label in Name) asks that it does, so a voice-control user can say what they see. Dropping the aria-label and keeping the title fixes it. The assertion at test/response-viewer-copy-markdown.test.ts:258 would then check the title instead.

You asked three questions under For review: whether the button needs a setting, whether the full view should copy the last answered turn rather than the whole conversation, and whether to keep the brief answer when the full view has none. Those are design decisions and they are still open. Neither point above depends on them.

Both points are small enough to apply on top when this lands, so you don't need to push anything for them.

@Ark0N
Ark0N merged commit b4e44c4 into Ark0N:master Nov 12, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Nov 12, 2026
…on it, and the full view's fallback has a test (#810)

The button's aria-label replaced its visible text as the accessible name with
a sentence that does not contain "Copy Markdown" (WCAG 2.5.3, Label in Name),
so a voice-control user could not say what they see. The label is gone and the
sentence stays as the title.

No test reached the line that keeps the brief view's answer when the full view
holds no answer of its own: the existing case returns earlier, on an empty
list. A full view that holds only a prompt reaches it.

The PR's own changeset is folded into the 1.51.0 one.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Nov 12, 2026
… run (#804, #807)

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@Ark0N

Ark0N commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Merged, and it shipped today in Codeman 1.51.0, with you in the release's Thanks. Thank you, @aakhter.

The two items from the review went in at merge time: the button no longer carries an aria-label, so it is named by the words on it and the longer sentence stays as its title, and a new test reaches the line that keeps the brief answer when the full view has none of its own (it fails with the fallback removed).

Your three questions: it ships as you built it. No setting (the viewer is already opt-in), the full view copies the last answered turn, and the brief answer is kept when the full view holds none.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants